Skip to content

feat(skills): detect skill-lock drift and flag a stale hub catalog - #2929

Merged
kovtcharov-amd merged 6 commits into
mainfrom
skills/lock-drift-and-catalog-staleness
Sep 25, 2026
Merged

kovtcharov-amd merged 6 commits into
mainfrom
skills/lock-drift-and-catalog-staleness

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Two guarantees in the skills marketplace looked enforced but weren't. skill-lock.json was written by every install and never read back, so a skill could be swapped, downgraded, or hand-edited afterwards and nothing noticed — including a verified one, still wearing a badge its new bytes never earned. And the offline catalog cache could serve months-old data with nothing saying so, which is how you install something that was unpublished weeks ago. Now install records a digest of what landed, gaia skill lock compares the lock against the disk, a signature-backed skill that drifted refuses to load, and every catalog result says how old it is.

One PR, two threads — same audit, same trust surface, and the second is small:

  • Lock drift — gaia skill lock [--check|--relock] reports version, tier, content, and presence drift. Drift is fatal for community/verified installs only: there an attestation exists to defeat, so the load path refuses and --relock won't re-record it (rewriting the digest would launder a signature). Experimental and locally-authored skills warn and keep working — editing a skill you wrote is the normal workflow, not an attack. Replacing an install via import/create --force/migrate --force now retires its lock entry, so the replacement isn't misread as tampered-with hub content.
  • Catalog staleness — the cache stamps its refresh time and carries age_seconds/stale through to gaia skill search and the UI payload, warning loudly past a week. Offline still works; it just stops claiming to be current.

gaia skill list reads no catalog, so it gained the local integrity signal (a drift warning) rather than a freshness one.

Test plan

  • pytest tests/unit/test_skills_drift.py tests/unit/test_hub_catalog.py — 26 + 42 pass
  • pytest tests/unit/test_skills_install.py tests/unit/test_skills_marketplace.py tests/unit/test_skills_manager.py tests/unit/test_skills_migrate.py — no regressions
  • Every new test was mutation-checked: breaking the thing it covers makes it fail. Spot-check by deleting content_digest=installed_digest in install.py (10 tests fail) or the assert_no_fatal_drift call in manager.py (4 fail).
  • Cold-state CLI walkthrough:
    export GAIA_CONFIG_DIR=/tmp/lockcheck
    gaia skill lock                       # clean, exit 0
    gaia skill create my-notes && gaia skill lock   # untracked, exit 4
    gaia skill lock --relock && gaia skill lock     # clean again, exit 0
    echo x >> $GAIA_CONFIG_DIR/skills/my-notes/SKILL.md
    gaia skill lock                       # content drift, advisory (unattested)
    gaia skill list                       # warns, still lists
    gaia skill info my-notes              # still loads
  • python util/lint.py --all (black/isort/flake8/pylint -E clean on the touched files)

@github-actions github-actions Bot added documentation Documentation changes tests Test changes labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This makes skill-lock.json something GAIA actually reads back: a signature-backed skill whose files changed after install now refuses to load, gaia skill lock reports and re-records drift, and an offline catalog says how old it is instead of passing months-old data off as current. The design is the right one — in particular, --relock refusing to re-stamp a community/verified skill whose bytes changed is exactly the hole a lockfile exists to close.

Two things to fix before merge:

Everyone who upgrades gets told their skills were tampered with. Locks written by the current release have no content digest, so on first gaia skill list after upgrading, every previously installed skill is reported as no longer matching the lock — which isn't true, nothing changed. gaia skill lock also exits non-zero for these users. The check itself is right; the message just needs to distinguish "can't be verified yet" from "doesn't match", and point at --relock as the one-time fix.

A damaged lock file now takes skill loading down with it. If skill-lock.json is corrupt or from a newer schema, listing skills fails outright and every skill in the user directory refuses to load — including purely local ones that never came from the hub and have no entry in it. Worth deciding whether an unreadable lock should be reported alongside the skill list rather than replacing it.

Real-world evidence

evidence-bundle.md reports that the evidence harness failed to run (auth/install/rate-limit — see the run logs), so nothing in this PR was exercised on a real surface here. gh was also unavailable in this run, so I could not read the PR description to see whether evidence lives there.

That leaves this verdict resting on static review alone for the user-visible surfaces this PR adds or changes: the new gaia skill lock / --relock / --json command, the new drift warning on gaia skill list, and the reworded staleness banner on gaia skill search. None of those output paths was run. The unit tests are strong and do cover the CLI handlers directly, but they assert exit codes and substrings — they don't show the rendered output a user reads, which is precisely where both blocking findings live. A short real gaia skill lock transcript (clean, drifted, and refused-relock) would close the gap; if the description already carries one, disregard.

🔍 Technical details

🟡 Important

1. Pre-digest lock entries are reported as content mismatch (src/gaia/skills/cli.py:525)

LockEntry.content_digest defaults to "", so every entry written before this PR yields a DRIFT_UNRECORDED from _content_drift (drift.py:985). notable in _handle_list filters out only DRIFT_UNTRACKED, so UNRECORDED (and MISSING) fall into a message that claims the files "no longer match" — false for both kinds. Same path makes gaia skill lock exit 4 for every upgrading user with a hub install.

test_a_lock_entry_without_a_digest_reports_as_unverifiable covers check_drift for this case, but nothing covers what gaia skill list prints for it.

Cheapest correct fix is to key the wording off the drift kinds present:

    if notable:
        blocking = sum(1 for d in notable if d.fatal)
        unverifiable = [d for d in notable if d.kind == DRIFT_UNRECORDED]
        names = ", ".join(sorted({d.skill for d in notable}))
        if len(unverifiable) == len(notable):
            print(
                f"\n⚠ {len(notable)} skill(s) predate content digests, so "
                f"{drift.lock_file.name} cannot verify them ({names}). "
                "Record them once with: gaia skill lock --relock",
                file=sys.stderr,
            )
        else:
            print(
                f"\n⚠ {len(notable)} skill(s) differ from "
                f"{drift.lock_file.name} ({names}"
                f"{f'; {blocking} will refuse to load' if blocking else ''}). "
                "Details: gaia skill lock --check",
                file=sys.stderr,
            )

(needs DRIFT_UNRECORDED added to the local import on cli.py:477.) Worth a gaia skill list test that seeds a digest-less entry and asserts the wording.

2. Lock read failures now propagate through list and load (src/gaia/skills/cli.py:484, src/gaia/skills/manager.py:280)

SkillLock.load raises SkillValidationError on unreadable JSON or a schema-version mismatch (lock.py:159-179). _handle_list calls check_drift unconditionally before rendering anything, and SkillManager.load calls assert_no_fatal_drift for every ROOT_USER skill. Net effect: a truncated lock file (partial write, full disk) or a downgrade after a future schema bump means gaia skill list prints nothing at all, and a locally-authored skill with no lock entry whatsoever fails to load.

Not arguing for a silent fallback — the error should still surface. The question is whether it should replace the listing rather than accompany it. Reporting it the way manager.discovery_errors are reported keeps the failure loud and the command useful. Your call; flagging it because the blast radius is wider than the marketplace feature that owns the file.

🟢 Minor

3. Relock claims success when it re-recorded nothing (src/gaia/skills/cli.py:1046)

When every drifted entry is refused, changed is False but refused is non-empty, so the output leads with ✅ Re-recorded … and only then prints ❌ Refused to re-record ….

    if result.changed:
        print(f"✅ Re-recorded {result.lock_file}")

4. Staleness banner is ungrammatical (src/gaia/skills/cli.py:894)

age_text is a phrase ("93 days ago"), so this renders as "This catalog is 93 days ago — over 7 days old." The same broken sentence is copied into docs/reference/cli.mdx:63.

            f"⚠ This catalog was last refreshed {found.age_text} — over {days} "
            "days old. The hub "

5. relock hashes each directory twice (src/gaia/skills/drift.py:1286)

_inspect computes content_digest(directory) to detect drift, then line 1301 computes it again to store it. Fine for small skills, wasteful for one with a large resources/ folder.

6. Agent.load_skill's Raises: list is now incomplete (src/gaia/agents/base/agent.py:1182)

It enumerates SkillNotFoundError / SkillValidationError / SkillPermissionError; SkillDriftError can now propagate through it too.

Strengths

  • --relock refuses to launder an attestation. The obvious implementation would rewrite the digest on request; refusing for community/verified and offering the two honest exits (reinstall, or remove + re-import at experimental) is the part that makes the feature mean anything. test_relock_refuses_to_re_record_an_attested_skill_that_drifted asserts the skill still refuses to load afterwards — the check that catches a regression here.
  • Fixtures run the real publish → install path. Lock entries in test_skills_drift.py come from Marketplace.publish / .install against the stand-in hub, so if install_skill stopped recording the digest the tests fail. A hand-built LockEntry fixture would have kept passing.
  • forget_skill is wired into all three replace paths (import, create --force, migrate --force), which is what stops a local replacement from reading as tampered-with hub content — the failure mode this feature would otherwise have introduced.
  • Reusing audit.findings.content_digest gives one content identity across audit, publish, and install, and inherits its __pycache__/.pyc exclusions — so importing a skill's tools.py can't trip a false fatal drift.
  • The docs land in all three places the claim lives (CLI reference, skills spec, format plan), including the honest note that skill-lock.json has no integrity protection of its own.

@itomek itomek self-assigned this Aug 24, 2026
itomek
itomek previously approved these changes Aug 25, 2026
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Both blocking findings are fixed, and chasing the first one turned up something worse in the same code path. Rebased onto main first (35 commits, no conflicts), so all of this is against current main.

"Everyone who upgrades gets told their skills were tampered with" — correct, and the wording split is the right fix. gaia skill list now says a pre-digest entry cannot be verified; a genuine mismatch still says no longer match. gaia skill lock gets the same split in its header.

"A damaged lock takes skill loading down with it" — half. gaia skill list now reports the unreadable lock beside the inventory instead of in place of it, so the command stays useful. Loading still refuses every user-root skill, local ones included, and I'd keep it that way: with the lock unreadable nothing can tell an attested skill from a local one, so degrading to "load everything" would let truncating one file switch the whole check off. Documented rather than changed.

🔒 SECURITY CONCERN — attestation laundering. Unreleased; it existed only on this branch and is fixed in this push. @kovtcharov-amd for visibility. --relock refused a signature-backed skill whose files had changed, but not one with no digest recorded at all — and no-digest is the state every upgrade from a pre-digest build starts in. The upgrade message this review asked me to write would have pointed every one of those users straight at it. Now refused, with reinstall from the hub as the honest exit.

Two more fixed (relock claiming success after refusing everything; the ungrammatical staleness banner) and one declined (the double hash) — reasoning below.

Evidence: gaia skill lock, --relock, gaia skill list, and the damaged-lock path were each run through the real CLI against an isolated GAIA_CONFIG_DIR, and the rendered output is now asserted in tests rather than just the exit codes.

🔍 Technical details

1. Pre-digest entries reported as mismatch — fixed. _handle_list splits notable into DRIFT_UNRECORDED and everything else, one message each; _handle_lock prints a predate content digests header when every non-UNTRACKED drift is UNRECORDED. I excluded UNTRACKED from that test rather than requiring the whole report to be unrecorded — one leftover create/import directory would otherwise have flipped the header straight back to "difference(s)".

gaia skill lock still exits 4 on this state, deliberately: it is an explicit verification verb, and "could not verify" is not "verified clean". gaia skill list exits 0. Both are now stated in docs/reference/cli.mdx so nobody gates CI on it blind.

2. Lock read failures — half taken. _handle_list catches SkillValidationError from check_drift into a lock_error reported alongside the table (and as a lock_error key in --json, with drift: null); exit stays 4. SkillManager.load is unchanged, and test_a_damaged_lock_still_refuses_to_load_a_user_root_skill pins that as a decision rather than an oversight.

3–4. Relock success line, staleness grammar — fixed as suggested, plus the doc copy.

5. Double hash in relock — declined. Threading the digest out of _inspect makes the common check_drift path carry a value it discards to save I/O in a rare, manually invoked command, and introduces a staleness window between the hash that verifies and the hash that gets stored. If skill directories ever grow large enough for this to matter the fix belongs in content_digest, not its callers.

6. Agent.load_skill Raises: — fixed, and its SkillValidationError entry was stale too: since the drift check landed it also fires when skill-lock.json is unreadable, which is a different cause with a different fix.

The laundering hole. _content_drift returns DRIFT_UNRECORDED with fatal=False for an entry with no digest — correct for the load path, since bricking every attested skill on upgrade would be worse. But relock refused only on d.fatal, so it recorded a fresh digest for a community entry under its existing installed_tier and signature.signed. Verified end-to-end against the real publish → install path before fixing: updated: ['web-research'], refused: [], check_drift().fatal == (), loads clean at community.

Refusal now keys off _would_launder(entry, drift) — fatal, or unrecorded on an attested entry — and _content_drift's remediation branches on _attested so it names gaia skill install <name> --force instead of a relock that will refuse. test_relock_refuses_to_stamp_a_digest_onto_a_pre_digest_attested_entry is the regression guard; test_relock_still_records_a_pre_digest_entry_that_nothing_attested keeps the refusal scoped to attestation.

Docs. cli.mdx (upgrade path, exit codes, corrupt-lock blast radius, the widened --relock refusal), spec/agent-skills.mdx, and plans/skill-format.mdx were updated together — the relock refusal and the corrupt-lock behavior are each described in more than one of them.

Validation. black / isort / flake8 / pylint clean; 1383 skills + hub tests pass. Nine new tests, seven of which fail on the pre-fix code. No LLM-affecting path is touched, so no eval run.

@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

This PR closes the "lock was write-only" gap: skills installed at a signature-backed tier can now be detected and refused when their bytes change after install. The security model is sound, the 726-line test suite is thorough, and the docs are accurate and internally consistent.

One gap worth fixing before merge:

🟡 gaia skill lock crashes on a corrupt lock; gaia skill list handles it cleanly

_handle_lock() calls check_drift() and relock() directly with no error handling. Both delegate to SkillLock.load(), which raises SkillValidationError on a truncated or future-schema skill-lock.json. The user gets a traceback instead of the clean exit 4 the docs describe. _handle_list() in the same file shows the right pattern: wrap check_drift() in try/except SkillValidationError. Neither the --check nor the --relock path is covered by a test for this case (there is a test for gaia skill list with a truncated lock at line 655 of test_skills_drift.py, but not one for gaia skill lock).

🔍 Technical details

src/gaia/skills/cli.py — _handle_lock() (around line 1026):

# current — no error handling for corrupt lock
report = check_drift(root)

# fix — mirror _handle_list's pattern
lock_error = ""
try:
    report = check_drift(root)
except SkillValidationError as exc:
    lock_error = str(exc)
    if getattr(args, "as_json", False):
        print(json.dumps({"error": lock_error}, indent=2))
    else:
        print(f"\n❌ {lock_error}", file=sys.stderr)
    return EXIT_INVALID

Same guard needed for the --relock path: wrap relock(root) in the same try/except before passing to _render_relock.

Test to add alongside the existing test_cli_skill_list_reports_a_damaged_lock_without_hiding_the_skills:

def test_cli_skill_lock_reports_a_damaged_lock_cleanly(
    marketplace, attested, monkeypatch, capsys
):
    monkeypatch.setattr(skills_cli, "_manager", lambda: marketplace.manager)
    (marketplace.skills_root / "skill-lock.json").write_text("{trunca", "utf-8")

    assert skills_cli.handle(_lock_args()) == skills_cli.EXIT_INVALID
    assert "not readable JSON" in capsys.readouterr().err

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

gaia skill lock doesn't crash on a corrupt lock — it already exits 4 with the message, on every variant I could construct. I ran it rather than reading it: truncated file and future schema version, each through --check, --relock, and --json. Six runs, six clean exits, no traceback.

The suggested local try/except would be a second copy of one that already exists a few frames up: handle() catches SkillValidationError for every gaia skill verb and turns it into ❌ <message> + exit 4. Adding it inside _handle_lock changes no output.

What the review got right is that none of this was tested, and that is worth fixing — if someone narrows handle()'s except clauses later, gaia skill lock starts throwing and nothing catches it. Pushed five cases that cover exactly that.

The one thing I deliberately did not copy from the suggestion is --json printing {"error": ...}. On a hard failure every other --json verb writes nothing to stdout and signals through the exit code (gaia skill info --json on a missing skill does the same with exit 3). gaia skill list --json carries an in-band lock_error because it still has an inventory to emit alongside the failure; lock has nothing to show, so silence plus exit 4 is the consistent answer.

🔍 Technical details

Why there is no traceback. handle() (src/gaia/skills/cli.py:461-471) wraps the handler dispatch in except SkillNotFoundError / except SkillValidationError / except SkillError. SkillLock.load raises SkillValidationError on both unreadable JSON and a schema mismatch, and every skills exception subclasses SkillError, so check_drift() and relock() raising out of _handle_lock lands in that arm. EXIT_INVALID is 4, which matches what docs/reference/cli.mdx promises — I checked the constant rather than trusting the number.

Real-CLI evidence (isolated GAIA_CONFIG_DIR, one experimental skill, lock overwritten per row):

lock --check   exit=4 traceback=no stdout_bytes=0 err=is not readable JSON
lock --relock  exit=4 traceback=no stdout_bytes=0 err=is not readable JSON
lock --json    exit=4 traceback=no stdout_bytes=0 err=is not readable JSON
lock --check   exit=4 traceback=no stdout_bytes=0 err=is schema v99
lock --relock  exit=4 traceback=no stdout_bytes=0 err=is schema v99
lock --json    exit=4 traceback=no stdout_bytes=0 err=is schema v99

The tests. test_cli_skill_lock_reports_a_damaged_lock_cleanly is parametrized over {truncated, future-schema} × {--check, --relock}; test_cli_skill_lock_json_does_not_emit_a_half_report_for_a_damaged_lock asserts empty stdout. Mutation-checked: removing handle()'s try/except entirely fails all five. Removing only the SkillValidationError arm does not fail them, because the SkillError arm below is identical — the property pinned is "exit 4 with an actionable message", not which clause delivers it.

tests/unit/test_skills_drift.py only; no source change. _lock_args() moved to setdefault so as_json=True can be passed without colliding with the default.

Validation. black / isort / flake8 clean on the changed file; the five pure-Python util/lint.py gates (security-suppressions, imports, agents, dependabot, doc-versions) pass. util/lint.py --all itself cannot run in this environment — it shells out through uvx, which cannot reach PyPI here — so the formatter and linter were run from the local install instead. 1218 skills + hub-catalog tests pass; the three test_real_cli_* failures are a pre-existing Windows-local env issue (the subprocess helper scrubs USERPROFILE) and reproduce on an unmodified checkout.

Not fixed, flagging it. relock() writes through SkillLock.save(), whose mkdir/write_text are unguarded — a read-only or full disk would escape as a genuine traceback. That is a real gap, it is not the one reported, and it is shared with the install path, so it belongs in its own change rather than bolted onto this branch.

@github-actions

github-actions Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Skill audit

Skill Verdict Claimed tier Cleared tiers Findings Rules
hub/skills/transcribe-meeting ✅ ALLOW community experimental, community none —

✅ All audited skills cleared the tier they claim.

Per-finding detail is withheld here on purpose. Read it in the Security > Code scanning tab, or download the skill-audit-reports artifact from this run. Offending source text is withheld from CI everywhere — reproduce it locally with gaia skill audit <dir> --show-snippets.

@kovtcharov-amd
kovtcharov-amd force-pushed the skills/lock-drift-and-catalog-staleness branch from 85e875c to 0591ff1 Compare September 3, 2026 02:33
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🟡 gaia skill lock will traceback on a corrupt skill-lock.json — the command most likely to be run when the lock is broken crashes instead of printing a clean error.

The prior review flagged that a corrupt lock file replaced the gaia skill list output entirely. This push fixes that by wrapping check_drift() in a try/except SkillValidationError inside _handle_list. But the new _handle_lock handler has no such guard: if skill-lock.json is truncated, schema-mismatched, or otherwise unreadable, check_drift(root) raises SkillValidationError straight through to the user as a Python traceback. The same is true for the --relock path — relock() documents that it raises SkillValidationError and calls SkillLock.load directly.

The fix is the same pattern _handle_list already uses: wrap both the check_drift and relock calls in a try/except SkillValidationError, print ❌ {exc} to stderr, and return EXIT_INVALID.

🔍 Technical details

src/gaia/skills/cli.py — _handle_lock (new in this push):

def _handle_lock(args: argparse.Namespace) -> int:
    from gaia.skills.drift import ..., check_drift, relock

    root = _manager().user_root

    if getattr(args, "relock", False):
        return _render_relock(relock(root), root, as_json=args.as_json)  # ← raises

    report = check_drift(root)   # ← raises

Both check_drift and relock call SkillLock.load(root) → raises SkillValidationError on a corrupt or future-schema file. _handle_list wraps the same check_drift call in try/except SkillValidationError as exc: lock_error = str(exc) — _handle_lock should do the same.

    try:
        if getattr(args, "relock", False):
            return _render_relock(relock(root), root, as_json=args.as_json)
        report = check_drift(root)
    except SkillValidationError as exc:
        if args.as_json:
            print(json.dumps({"lock_error": str(exc)}, indent=2))
        else:
            print(f"❌ {exc}", file=sys.stderr)
        return EXIT_INVALID

SkillValidationError is already imported at the top of the file.

Ovtcharov added 4 commits September 8, 2026 09:48
`skill-lock.json` was written by every `gaia skill install` and never read
back, so a skill could be swapped, downgraded, or hand-edited afterwards and
nothing noticed — including a `verified` one, whose badge then described bytes
its signature never covered. Install now records a digest of what landed,
`gaia skill lock` compares the lock against the disk, and `SkillManager.load`
refuses a signature-backed skill that no longer matches.

Drift is fatal only where an attestation exists to defeat: `community` /
`verified` hub installs refuse to load, and `--relock` refuses to re-record
them, because rewriting the digest would launder a signature. Experimental and
locally-authored skills warn and keep working — nothing attested them, and
editing a skill you wrote is the normal workflow.

Separately, the offline catalog cache could serve months-old data with no
indication. It now stamps its refresh time, carries `age_seconds` / `stale`
through to the CLI and the UI payload, and warns loudly past a week. Offline
still works; it just no longer claims to be current.
The `if TYPE_CHECKING:` block sat between the stdlib imports and the
first-party ones. Pylint treats any non-import statement as the end of the
import section, so all eight `gaia.*` imports below it were flagged C0413
wrong-import-position and the Code Quality job failed.

Moving the guard below the imports fixes it with no behaviour change. The
deferred import it exists for is untouched: `drift` is still imported inside
the two verbs that use it, keeping the marketplace modules off the import path
of every other `gaia skill` invocation.
Locks written before this branch have no content digest, so the first
`gaia skill list` after upgrading reported every previously installed skill
as no longer matching the lock. Nothing had changed — the lock simply could
not check. The wording now separates "cannot be verified yet" from "does not
match", and a damaged `skill-lock.json` is reported beside the skill
inventory instead of replacing it.

Chasing that message surfaced a hole it would have made the default path:
`--relock` re-recorded a `community`/`verified` entry that had *no* digest,
stamping whatever was on disk with the publisher's signature. Fatal drift
was refused; a missing digest was not — and a missing digest is exactly
where every upgrade starts. It is now refused too, pointing at
`gaia skill install <name> --force`, which takes the digest from the hub's
own verified bytes.

Also: `--relock` no longer prints "Re-recorded" when every entry was
refused; the catalog staleness banner reads as a sentence ("was last
refreshed 93 days ago"); `Agent.load_skill` documents `SkillDriftError` and
the unreadable-lock cause of `SkillValidationError`.
A truncated or future-schema `skill-lock.json` already exits 4 with the
message rather than a traceback — `handle()`'s `SkillValidationError` arm
covers `_handle_lock` the same way it covers every other verb. Nothing
asserted it, so narrowing that arm would turn every corrupt-lock run into a
stack trace and no test would notice.

Five cases now cover it: `--check` and `--relock`, against both a truncated
file and a future schema version, plus `--json` emitting nothing on stdout
rather than a report for a check that never ran. All five fail if `handle()`
stops catching.
@kovtcharov-amd
kovtcharov-amd force-pushed the skills/lock-drift-and-catalog-staleness branch from 0591ff1 to 6f7577f Compare September 8, 2026 16:50
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

No change needed here — gaia skill lock already prints a clean, actionable error and exits 4 on a corrupt lock file. It does not traceback.

I checked this by running the real CLI (not the unit tests) against both a truncated lock and a future-schema lock, on the lock, --relock and --json paths. All six combinations print the message and exit 4:

❌ Skill lock …/skill-lock.json is not readable JSON: Expecting property name … .
   Fix the file, or delete it and re-run 'gaia skill install' for each skill you
   want tracked — GAIA will not silently discard the provenance of skills that
   are still installed.
EXIT=4

_handle_lock has no local try/except on purpose. _handle_list needs one because it still has a skill list to render alongside the failure; lock has nothing to render, so the dispatcher's error arm is the whole error path — and narrowing it is exactly what the five tests added in 6f7577f0c exist to catch.

The one substantive difference from the suggestion is --json, which writes the error to stderr and leaves stdout empty rather than emitting {"lock_error": …}. That is deliberate and pinned by a test: nothing on stdout is a better signal than a JSON body describing a check that never ran.

🔍 Technical details

handle() (src/gaia/skills/cli.py:468-478) wraps every handler, lock included:

try:
    return handler(args)
except SkillNotFoundError as exc:
    sys.stderr.write(f"❌ {exc}\n"); return EXIT_NOT_FOUND
except SkillValidationError as exc:
    sys.stderr.write(f"❌ {exc}\n"); return EXIT_INVALID
except SkillError as exc:
    sys.stderr.write(f"❌ {exc}\n"); return EXIT_INVALID

_handle_lock is registered in that same handlers dict (line 452), so both check_drift(root) and relock(root) land in the SkillValidationError arm.

Reproduction, via GAIA_CONFIG_DIR so it does not touch a real ~/.gaia:

export GAIA_CONFIG_DIR=$(mktemp -d)/g && mkdir -p "$GAIA_CONFIG_DIR/skills"
printf '{trunca' > "$GAIA_CONFIG_DIR/skills/skill-lock.json"
gaia skill lock;           echo "EXIT=$?"   # EXIT=4
gaia skill lock --relock;  echo "EXIT=$?"   # EXIT=4
gaia skill lock --json;    echo "EXIT=$?"   # EXIT=4, stdout empty

Repeated with {"schema_version": 99, "skills": {}} → is schema v99; this GAIA build reads v1. Upgrade GAIA, or delete the lock and reinstall your skills., exit 4.

Coverage: tests/unit/test_skills_drift.py::test_cli_skill_lock_reports_a_damaged_lock_cleanly (4 params: {check, relock} × {truncated, future-schema}) and ::test_cli_skill_lock_json_does_not_emit_a_half_report_for_a_damaged_lock. Full file: 41 passed.

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

The red checks here were measuring a main from two weeks ago, so they told you nothing about whether this PR still works. Merged current main in (208 commits, clean, no conflicts) to get a real verdict from CI.

🔍 Technical details
  • The Email Triage Eval (synthetic corpus, report mode) failure dated from Sep 8; that lane has been rewritten twice on main since (1dc04651f, ed90d9ee3), so the old result is not comparable.
  • The branch's own four commits are untouched — this is a merge commit only, no rebase, no force-push.
  • tests/unit/test_skills_drift.py, test_skills_cli.py, test_hub_catalog.py on the merged tree: 117 passed, 3 failed. The three are @pytest.mark.slow test_real_cli_* cases that fail on Windows only — _real_cli builds a scrubbed env with HOME but no USERPROFILE, so Python 3.13's Path.home() raises. Same helper is on main, so it is pre-existing and Linux CI is unaffected.
  • rag_quality + context_retention + tool_selection vs Gemma baselines will stay red — that gate is down repo-wide while the judge is spend-capped (ci(eval): every eval gate dies in preflight — the judge org is out of monthly spend #4080), not a signal about this branch.

Conflicts in the two files main also grew skill-lock surface in:

- skills/lock.py: main added SOURCE_CAPTURED, this branch added SOURCE_LOCAL.
  Both are live (capture.py and drift.py respectively), so both are kept.
- skills/cli.py: main added the 'promote' subparser, this branch added 'lock'.
  Both registered.
- reference/cli.mdx: same pair, as sibling <Tab> blocks.
@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 8705c51 Sep 25, 2026
51 of 61 checks passed
@kovtcharov-amd
kovtcharov-amd deleted the skills/lock-drift-and-catalog-staleness branch September 25, 2026 18:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants